Proyecto final - Análisis de Toxicidad en Tweets

Author

Patricio Porras

1. Carga y Comprensión de datos

En esta primera fase, importaremos las librerías necesarias, cargaremos el conjunto de datos y realizaremos un Análisis Exploratorio de Datos (EDA) para entender la estructura, calidad y características de la información con la que trabajaremos.

Importar librerías

print("Importar librerías")

# Manipulación de datos
import altair as alt
import pandas as pd
import numpy as np

# Procesamiento de Texto (NLP)
import spacy
import nltk

from nltk.corpus import stopwords
from nltk import word_tokenize # tokenizacion
from nltk import pos_tag #lematizacion
from nltk.stem import WordNetLemmatizer
from nltk.corpus import wordnet

# Procesamiento de texto y features
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, LabelEncoder, StandardScaler, label_binarize
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

nltk.download('stopwords') # necessary for removal of stop words
nltk.download('wordnet') # necessary for lemmatization

# Modelo de clasificación
from sklearn.linear_model import LogisticRegression, Ridge

# Métricas de Evaluación
from sklearn.metrics import (
    classification_report, 
    confusion_matrix, 
    ConfusionMatrixDisplay,
    roc_auc_score, 
    RocCurveDisplay,
    mean_squared_error, 
    r2_score,
    silhouette_score, 
    roc_curve, 
    auc
)

# Visualización
import matplotlib.pyplot as plt
import seaborn as sns

# Configuración de visualización
sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

from sklearn.cluster import KMeans
from sklearn.datasets import fetch_openml

import re
import unicodedata

# Visualización
import matplotlib.pyplot as plt
import seaborn as sns

# Visualización de textos
from wordcloud import WordCloud

# Configuraciones
import warnings
warnings.filterwarnings('ignore')

# Descargar recursos de NLTK (stopwords)
nltk.download('stopwords', quiet=True)

# Cargar modelo de SpaCy para español (para lematización)
!python -m spacy download es_core_news_sm -q
nlp_spacy = spacy.load('es_core_news_sm')
Importar librerías
[nltk_data] Downloading package stopwords to
[nltk_data]     C:\Users\Lenovo\AppData\Roaming\nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
[nltk_data] Downloading package wordnet to
[nltk_data]     C:\Users\Lenovo\AppData\Roaming\nltk_data...
[nltk_data]   Package wordnet is already up-to-date!
[+] Download and installation successful

You can now load the package via spacy.load('es_core_news_sm')

Cargar dataset

print("Cargar dataset")

url = "https://raw.githubusercontent.com/erickedu85/dataset/refs/heads/master/tweets/1500_tweets_con_toxicity.csv"
df = pd.read_csv(url)
Cargar dataset

Función de Limpieza de Texto

Definimos la función personalizada que realizará la limpieza y lematización del texto en español, según lo solicitado.

# Obtenemos stopwords en español
stopwords_es = set(stopwords.words('spanish'))

def limpiar_y_lematizar(texto):
    if not isinstance(texto, str):
        return ""
    
    # 1. Reemplazar tildes (Normalización NFD)
    texto = unicodedata.normalize('NFD', texto).encode('ascii', 'ignore').decode('utf-8')
    
    # 2. Convertir a minúsculas
    texto = texto.lower()
    
    # 3. Eliminar URLs, menciones y hashtags
    texto = re.sub(r'http\S+|www\S+|https\S+', '', texto, flags=re.MULTILINE)
    texto = re.sub(r'[@#]\w+', '', texto)
    
    # 4. Eliminar puntuación y números
    texto = re.sub(r'[^a-zA-Z\s]', ' ', texto)
    
    # 5. Lematización con SpaCy y eliminación de stopwords
    doc = nlp_spacy(texto)
    lemmas = [
        token.lemma_ 
        for token in doc 
        if token.text not in stopwords_es and 
           not token.is_punct and 
           not token.is_space and 
           len(token.text) > 2 # eliminar tokens muy cortos
    ]
    
    return " ".join(lemmas)

# --- Prueba de la función ---
texto_ejemplo = df['content'].iloc[0]
print(f"--- Texto Original ---\n{texto_ejemplo}\n")
print(f"--- Texto Limpio y Lematizado ---\n{limpiar_y_lematizar(texto_ejemplo)}")
--- Texto Original ---
@DanielNoboaOk @DiegoBorjaPC Lávate el hocico presidente de cartón,habla la verdad y cómo son las cosas! Cómo han sido los tiempos y cómo han pasado las cosas,si no entiendes cómo son los procesos de contratación qué haces de presidente ignorante!Borja no debería ser candidato es correcto al tener un vínculo

--- Texto Limpio y Lematizado ---
lavatar hocico presidente carton hablar verdad cosa ser tiempo pasar cosa entender proceso contratacion hacer presidente ignorante borja deberio ser candidato correcto tener vinculo

EDA

Exploramos la estructura, los tipos de datos, los valores nulos y las distribuciones de las variables clave.

Primeras filas del dataset

print("Primeras filas del dataset")

# Visualizar las primeras 5 filas
df.head()
Primeras filas del dataset
tweetId tweetUrl content isReply replyTo createdAt authorId authorName authorUsername authorVerified ... inReplyToId Date time_response account_age_days mentions_count hashtags_count content_length has_profile_picture sentiment_polarity toxicity_score
0 1878630970745900800 https://x.com/Pableins15/status/18786309707459... @DanielNoboaOk @DiegoBorjaPC Lávate el hocico ... True DanielNoboaOk 2025-01-13 02:31:00 176948611 Pablo Balarezo Pableins15 False ... 1878539079249547520 2025-01-12 20:26:32 364.466667 5261 2 0 309 False 0.0 0.543256
1 1904041877503984128 https://x.com/solma1201/status/190404187750398... @DanielNoboaOk De esa arrastrada no te levanta... True DanielNoboaOk 2025-03-24 05:25:00 1368663286582030336 Solma1201 solma1201 False ... 1904003201143115776 2025-03-24 02:51:52 153.133333 1399 1 0 70 True 0.0 0.426917
2 1877463444649046016 https://x.com/Mediterran67794/status/187746344... @LuisaGonzalezEc @RC5Oficial Protegiendo a los... True LuisaGonzalezEc 2025-01-09 21:12:00 1851005619106451712 Médico Escritor Filósofo Hermeneútico Mediterran67794 False ... 1877158437236228352 2025-01-09 01:00:22 1211.633333 68 2 0 122 True 0.0 0.555970
3 1881356046108885248 https://x.com/ardededa/status/1881356046108885494 @DanielNoboaOk #NoboaPresidente. Todo 7! True DanielNoboaOk 2025-01-20 15:00:00 315799544 Denise ardededa False ... 1881165128185560832 2025-01-20 02:21:31 758.483333 4955 1 0 41 True 0.0 0.046615
4 1888331962063978752 https://x.com/LMarquinezm/status/1888331962063... @slider1908 @LuisaGonzalezEc @DianaAtamaint @c... True slider1908 2025-02-08 20:59:00 1551883554 Luis Marquínez LMarquinezm False ... 1888256000085397504 2025-02-08 14:59:07 359.883333 4208 5 0 101 True 0.0 0.846027

5 rows × 27 columns

Información del dataset

print("Información del dataset")

# Información general del dataset
df.info()
Información del dataset
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1500 entries, 0 to 1499
Data columns (total 27 columns):
 #   Column               Non-Null Count  Dtype  
---  ------               --------------  -----  
 0   tweetId              1500 non-null   int64  
 1   tweetUrl             1500 non-null   object 
 2   content              1500 non-null   object 
 3   isReply              1500 non-null   bool   
 4   replyTo              1490 non-null   object 
 5   createdAt            1500 non-null   object 
 6   authorId             1500 non-null   int64  
 7   authorName           1500 non-null   object 
 8   authorUsername       1500 non-null   object 
 9   authorVerified       1500 non-null   bool   
 10  authorFollowers      1500 non-null   int64  
 11  authorProfilePic     1500 non-null   object 
 12  authorJoinDate       1500 non-null   object 
 13  source               1500 non-null   object 
 14  hashtags             121 non-null    object 
 15  mentions             1499 non-null   object 
 16  conversationId       1500 non-null   int64  
 17  inReplyToId          1500 non-null   int64  
 18  Date                 1500 non-null   object 
 19  time_response        1500 non-null   float64
 20  account_age_days     1500 non-null   int64  
 21  mentions_count       1500 non-null   int64  
 22  hashtags_count       1500 non-null   int64  
 23  content_length       1500 non-null   int64  
 24  has_profile_picture  1500 non-null   bool   
 25  sentiment_polarity   1500 non-null   float64
 26  toxicity_score       1347 non-null   float64
dtypes: bool(3), float64(3), int64(9), object(12)
memory usage: 285.8+ KB

Detección de Valores Nulos

Identificamos las columnas con datos faltantes.

print("Conteo de valores nulos por columna:")
null_counts = df.isnull().sum()
null_counts_percent = (null_counts / len(df) * 100).round(2)
null_summary = pd.DataFrame({'conteo_nulos': null_counts, 'porcentaje_nulos': null_counts_percent})
print(null_summary[null_summary['conteo_nulos'] > 0])
Conteo de valores nulos por columna:
                conteo_nulos  porcentaje_nulos
replyTo                   10              0.67
hashtags                1379             91.93
mentions                   1              0.07
toxicity_score           153             10.20

Hallazgo Clave 1:

La variable toxicity_score, que es nuestro target principal, tiene 153 valores nulos (10.2% del dataset). Para los modelos supervisados (regresión y clasificación), vamos a eliminar estas filas, ya que no podemos entrenar sin una etiqueta.

Análisis del Target: toxicity_score

Analizamos la distribución de nuestra variable objetivo principal.

Analisis sin excluir valores nulos, representación en barras

alt.Chart(df).mark_bar().encode(
    x=alt.X('toxicity_score:Q', bin=True, title='Nivel de Toxicidad'),
    y=alt.Y('count():Q', title='Frecuencia'),
    tooltip=['toxicity_score:Q', 'count():Q']
).properties(
    title='Distribución de Toxicity Score'
).interactive()

Distribución completa del ‘toxicity_score’

Analisis sin excluir valores nulos, representación en círculos

alt.Chart(df).mark_circle().encode(
    alt.X('toxicity_score'),
    alt.Y('count()'),
    tooltip=['toxicity_score', 'count()']
).properties(
    title='Distribución de Toxicity Score'
).interactive()

Distribución completa del ‘toxicity_score’

Análisis descriptivo de: toxicity_score.

print("Estadísticas descriptivas de 'toxicity_score':")
print(df['toxicity_score'].describe())
Estadísticas descriptivas de 'toxicity_score':
count    1347.000000
mean        0.253879
std         0.243942
min         0.001940
25%         0.028444
50%         0.188392
75%         0.426917
max         0.939145
Name: toxicity_score, dtype: float64

Analisis sin valores nulos

# Gráfico interactivo con Altair
chart = alt.Chart(df.dropna(subset=['toxicity_score'])).mark_bar().encode(
    x=alt.X('toxicity_score', bin=alt.Bin(maxbins=50), title='Nivel de Toxicidad'),
    y=alt.Y('count()', title='Frecuencia'),
    tooltip=[alt.X('toxicity_score', bin=alt.Bin(maxbins=50)), 'count()']
).properties(
    title='Distribución de Toxicity Score'
)

density = alt.Chart(df.dropna(subset=['toxicity_score'])).transform_density(
    'toxicity_score',
    as_=['toxicity_score', 'density'],
).mark_line(color='red').encode(
    x=alt.X('toxicity_score', title='Nivel de Toxicidad'),
    y=alt.Y('density:Q', title='Densidad'),
)

# Combinar histograma y densidad (en diferentes ejes Y)
# (Para combinar en el mismo gráfico necesitarían escalas normalizadas,
# pero para exploración visual, dos gráficos alineados son efectivos.)

display(chart + density)

Distribución del ‘toxicity_score’

Hallazgo Clave 2:

La distribución de toxicity_score está fuertemente sesgada a la derecha (cola larga hacia valores altos), pero la gran mayoría de los tweets tiene un score de toxicidad bajo (cercano a 0). Esto es fundamental para la clasificación: si usamos un umbral fijo como 0.5, las clases resultarán muy desbalanceadas.

Análisis de Features Relevantes

Exploramos las variables que usaremos como features (predictoras).

print("Limpiamos valores nulos")
df_clean = df.dropna(subset=['toxicity_score'])
Limpiamos valores nulos

Graficamos histogramas de variables numéricas.

print("Distribución de Variables Numéricas:")

numeric_features = ['authorFollowers', 'time_response', 'account_age_days', 
                  'mentions_count', 'hashtags_count', 'content_length']

# Creamos una figura con 2 filas y 3 columnas
fig, axes = plt.subplots(nrows=2, ncols=3, figsize=(10, 5))
fig.suptitle('Distribución de Features Numéricos', fontsize=16, y=1.02)

# Aplanamos el array de ejes para iterar fácilmente
axes = axes.flatten()

for i, col in enumerate(numeric_features):
    sns.histplot(data=df_clean, x=col, kde=True, ax=axes[i])
    axes[i].set_title(f'Distribución de {col}')
    
    # Detección de sesgo alto: 'authorFollowers' y 'time_response'
    # Si están muy sesgadas, una escala logarítmica ayuda a visualizar
    if col in ['authorFollowers', 'time_response']:
        axes[i].set_xscale('log')
        axes[i].set_title(f'Distribución de {col} (Escala Log)')

plt.tight_layout()
plt.show()
Distribución de Variables Numéricas:

Histogramas de variables numéricas

Boxplots de Variables Numéricas (para detectar outliers).

print("Boxplots de Variables Numéricas (para detectar outliers):")

# Creamos una figura con 2 filas y 3 columnas
fig, axes = plt.subplots(nrows=2, ncols=3, figsize=(10, 5))
fig.suptitle('Boxplots de Features Numéricos', fontsize=16, y=1.02)

# Aplanamos el array de ejes
axes = axes.flatten()

for i, col in enumerate(numeric_features):
    sns.boxplot(data=df_clean, x=col, ax=axes[i])
    axes[i].set_title(f'Boxplot de {col}')
    
    # Aplicamos escala logarítmica a las mismas variables sesgadas
    if col in ['authorFollowers', 'time_response']:
        axes[i].set_xscale('log')
        axes[i].set_title(f'Boxplot de {col} (Escala Log)')

plt.tight_layout()
plt.show()
Boxplots de Variables Numéricas (para detectar outliers):

Boxplots de variables numéricas

Conteo de Variables Categóricas.

print("Conteo de Variables Categóricas:")

categorical_features = ['isReply', 'authorVerified', 'has_profile_picture', 'source']

# Creamos una figura con 2 filas y 2 columnas
fig, axes = plt.subplots(nrows=2, ncols=2, figsize=(8, 6))
fig.suptitle('Conteo de Features Categóricos', fontsize=16, y=1.02)

# isReply
sns.countplot(data=df_clean, x='isReply', ax=axes[0, 0])
axes[0, 0].set_title('Conteo de "isReply"')

# authorVerified
sns.countplot(data=df_clean, x='authorVerified', ax=axes[0, 1])
axes[0, 1].set_title('Conteo de "authorVerified"')

# has_profile_picture
sns.countplot(data=df_clean, x='has_profile_picture', ax=axes[1, 0])
axes[1, 0].set_title('Conteo de "has_profile_picture"')

# --- Tratamiento especial para 'source' ---
# Obtenemos el Top 10 de 'source'
top_10_sources = df_clean['source'].value_counts().nlargest(10).index

# Graficamos 'source' (Top 10) de forma horizontal para mejor lectura
sns.countplot(data=df_clean, y='source', order=top_10_sources, ax=axes[1, 1])
axes[1, 1].set_title('Top 10 de "source" (Plataforma)')
axes[1, 1].set_xlabel('Conteo')
axes[1, 1].set_ylabel('Source')

plt.tight_layout()
plt.show()
Conteo de Variables Categóricas:

Conteo de variables categóricas

Más datos.

# Variables Numéricas
numeric_features_list = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']
print("Estadísticas de Features Numéricos:")
display(df[numeric_features_list].describe())

# Variables Categóricas
categorical_features_list = ['isReply', 'authorVerified', 'has_profile_picture', 'source']
print("\nConteo de valores en Features Categóricos:")
for col in categorical_features_list:
    print(f"\n--- {col} ---")
    print(df[col].value_counts(normalize=True).head(10)) # .head(10) para 'source'
Estadísticas de Features Numéricos:
authorFollowers time_response account_age_days mentions_count hashtags_count content_length
count 1.500000e+03 1500.000000 1500.000000 1500.000000 1500.0 1500.000000
mean 3.625721e+03 1170.037578 2271.134000 1.723333 0.0 116.528000
std 1.184447e+05 3273.929808 1984.156805 0.946248 0.0 77.493712
min 0.000000e+00 0.133333 -90.000000 0.000000 0.0 17.000000
25% 7.000000e+00 136.300000 455.750000 1.000000 0.0 57.000000
50% 4.300000e+01 515.625000 1538.000000 2.000000 0.0 96.000000
75% 1.992500e+02 1265.716667 4420.750000 2.000000 0.0 150.000000
max 4.577730e+06 63569.000000 6506.000000 10.000000 0.0 684.000000

Conteo de valores en Features Categóricos:

--- isReply ---
isReply
True    1.0
Name: proportion, dtype: float64

--- authorVerified ---
authorVerified
False    1.0
Name: proportion, dtype: float64

--- has_profile_picture ---
has_profile_picture
True     0.955333
False    0.044667
Name: proportion, dtype: float64

--- source ---
source
Twitter for iPhone    1.0
Name: proportion, dtype: float64

Hallazgo Clave 3:

  • Alta Homogeneidad (Falta de Varianza): Tres de las cuatro variables categóricas son, en la práctica, constantes en tu dataset.
    • isReply: El 100% de tus datos son True. Esto significa que tu dataset se compone exclusivamente de respuestas, no hay tweets originales.
    • authorVerified: El 100% de los autores son False (no verificados). Tu dataset representa únicamente a usuarios “comunes”, no a cuentas oficiales o celebridades.
    • source: El 100% de los tweets provienen de Twitter for iPhone. No hay diversidad de clientes (como Android, Web App, etc.)
  • Única Variable Categórica Útil: has_profile_picture: Esta es la única variable categórica de este grupo que tiene varianza (95.5% True vs 4.5% False). Por lo tanto, es la única que puede aportar poder predictivo al modelo.
  • Se eliminarán las variables: isReply, authorVerified, source.

Generando Nube de Palabras del texto limpio.

print("Generando Nube de Palabras del texto limpio...")

# 1. Aplicamos la limpieza (lematización, stopwords, etc.)
# Esto puede tardar un momento
text_limpio = df_clean['content'].apply(limpiar_y_lematizar)

# 2. Unimos todo el texto en un solo string
full_text = " ".join(text_limpio)

# 3. Generamos la nube de palabras
wordcloud = WordCloud(width=1200, height=600, 
                      background_color='white', 
                      colormap='viridis',
                      max_words=150
                     ).generate(full_text)

# 4. Mostramos la imagen
plt.figure(figsize=(15, 7))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis('off')
plt.title('Nube de Palabras más Frecuentes (Lematizadas)', fontsize=16)
plt.show()
Generando Nube de Palabras del texto limpio...

Nube de palabras del contenido de los tweets

Gráfico de Frecuencias (Top 20 Palabras).

print("Generando Gráfico de Frecuencias del texto limpio...")

# Usamos CountVectorizer con nuestra función de limpieza
vec = CountVectorizer(preprocessor=limpiar_y_lematizar)

# Obtenemos la matriz de conteo
text_counts = vec.fit_transform(df_clean['content'])

# Sumamos las ocurrencias de cada palabra
sum_words = text_counts.sum(axis=0) 
words_freq = [(word, sum_words[0, idx]) for word, idx in vec.vocabulary_.items()]
words_freq = sorted(words_freq, key = lambda x: x[1], reverse=True)

# Creamos un DataFrame con el Top 20
top_words_df = pd.DataFrame(words_freq[:20], columns=['Palabra', 'Frecuencia'])

# Graficamos
plt.figure(figsize=(15, 8))
sns.barplot(data=top_words_df, x='Frecuencia', y='Palabra', palette='plasma')
plt.title('Top 20 Palabras más Frecuentes (Lematizadas)')
plt.xlabel('Frecuencia Total')
plt.ylabel('Palabra')
plt.show()
Generando Gráfico de Frecuencias del texto limpio...

Top 20 palabras más frecuentes (lematizadas)

Heatmap de Correlación (Features Numéricos y Target).

print("Heatmap de Correlación (Features Numéricos y Target)")

# Seleccionamos solo las columnas numéricas y el target
numeric_and_target = numeric_features + ['toxicity_score']
df_corr = df_clean[numeric_and_target]

# Calculamos la matriz de correlación
corr_matrix = df_corr.corr()

# Graficamos el heatmap
plt.figure(figsize=(12, 8))
sns.heatmap(corr_matrix, 
            annot=True,     # Mostrar los valores numéricos
            cmap='vlag',    # Paleta de colores (rojo-blanco-azul)
            fmt=".2f",      # Formato con 2 decimales
            linewidths=0.5)
plt.title('Heatmap de Correlación (Spearman)', fontsize=16)
plt.show()
Heatmap de Correlación (Features Numéricos y Target)

Heatmap de correlación

Gráfico de Dispersión Específico (content_length vs toxicity_score)

# Usamos jointplot para ver el scatter y los histogramas
sns.jointplot(data=df_clean, 
              x='content_length', 
              y='toxicity_score', 
              kind='reg', # 'reg' añade una línea de regresión
              joint_kws={'line_kws': {'color': 'red'}},
              scatter_kws={'alpha': 0.3})
plt.suptitle('Longitud del Tweet vs. Score de Toxicidad', y=1.02)
plt.show()

Longitud del contenido vs. Toxicidad

2. Preprocesamiento de datos

En esta sección, preparamos los datos para el modelado:

  1. Manejamos los nulos del target.
  2. Definimos las variables X (features) e y (targets).
  3. Creamos la función de limpieza de texto (NLP) que incluye lematización.

Limpieza de Nulos y Creación de Targets

Como se decidió en el EDA, eliminamos las filas donde toxicity_score es nulo para los modelos supervisados.

print(f"Tamaño original: {df.shape}")
print("Limpiamos valores nulos del target y eliminamos columnas sin varianza")
df_clean = df.dropna(subset=['toxicity_score'])

# Eliminamos las columnas identificadas en el Hallazgo Clave 3 por falta de varianza
cols_to_drop = ['isReply', 'authorVerified', 'source']
df_clean = df_clean.drop(columns=cols_to_drop)

print(f"Tamaño después de eliminar nulos en target: {df_clean.shape}")

# Definición de Targets
# 1. Target de Regresión (continuo)
y_reg = df_clean['toxicity_score']

# 2. Target de Clasificación (Multiclase por Cuartiles)
# Usamos pd.qcut para dividir en 4 clases (cuartiles)
# q=4 significa 4 grupos con ~25% de los datos cada uno.
# labels=False nos da clases numéricas: 0, 1, 2, 3
# duplicates='drop' maneja el caso donde hay muchos valores idénticos (ej. 0.0)
try:
    y_class = pd.qcut(df_clean['toxicity_score'], q=4, labels=False, duplicates='drop')
except ValueError as e:
    print(f"Advertencia al crear cuartiles: {e}")
    # Si 'drop' no es suficiente (datos muy sesgados), se reduce el número de cuantiles
    y_class = pd.qcut(df_clean['toxicity_score'], q=4, labels=False, duplicates='raise').astype(str)


# Definimos los nombres de las etiquetas para usarlos en la evaluación
class_labels = ['Q1 (Bajo)', 'Q2 (Moderado-Bajo)', 'Q3 (Moderado-Alto)', 'Q4 (Alto)']

# Revisamos el balance de clases (debería ser ~25% por definición)
print("\nBalance de clases para el target multiclase (Cuartiles):")
print(y_class.value_counts(normalize=True).sort_index())
Tamaño original: (1500, 27)
Limpiamos valores nulos del target y eliminamos columnas sin varianza
Tamaño después de eliminar nulos en target: (1347, 24)

Balance de clases para el target multiclase (Cuartiles):
toxicity_score
0    0.250186
1    0.250928
2    0.253155
3    0.245731
Name: proportion, dtype: float64

Nota sobre Desbalanceo: Como se anticipó, la clase 1 (tóxico) representa solo el 24% de los datos. Usaremos class_weight='balanced' en el modelo de clasificación para mitigar esto.

Definición de Features (X) y Targets (y)

Seleccionamos las columnas que servirán como features.

# Columnas de features identificadas en el EDA
text_features = 'content'
numeric_features = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']
categorical_features = ['has_profile_picture']

# Creamos el DataFrame X de features
X = df_clean[numeric_features + categorical_features + [text_features]]

print(f"Dimensiones de X (features): {X.shape}")
print(f"Dimensiones de y_reg (target regresión): {y_reg.shape}")
print(f"Dimensiones de y_class (target clasificación): {y_class.shape}")
Dimensiones de X (features): (1347, 8)
Dimensiones de y_reg (target regresión): (1347,)
Dimensiones de y_class (target clasificación): (1347,)

3. División de datos

Dividimos los datos en conjuntos de entrenamiento (train) y prueba (test) para poder evaluar nuestros modelos de forma objetiva.

Separación de features y target

Ya tenemos X (features) e y_reg / y_class (targets) definidos en la sección anterior.

Split

Usamos train_test_split para crear las divisiones. Es crucial dividir X, y_reg e y_class simultáneamente para mantener la alineación de los índices.

# Dividimos los datos
X_train, X_test, y_train_reg, y_test_reg, y_train_class, y_test_class = train_test_split(
    X, 
    y_reg, 
    y_class, 
    test_size=0.25,  # 25% para test
    random_state=42,
    stratify=y_class # Estratificamos por el target de clasificación para mantener la proporción
)

print(f"Tamaño X_train: {X_train.shape}")
print(f"Tamaño X_test: {X_test.shape}")
print(f"Tamaño y_train_reg: {y_train_reg.shape}")
print(f"Tamaño y_test_class: {y_test_class.shape}")
Tamaño X_train: (1010, 8)
Tamaño X_test: (337, 8)
Tamaño y_train_reg: (1010,)
Tamaño y_test_class: (337,)

4. Entrenamiento del modelo

Aquí definimos el ColumnTransformer y entrenamos los tres modelos solicitados.

Pipeline (ColumnTransformer)

Creamos el pipeline de preprocesamiento principal usando ColumnTransformer. Este se encargará de aplicar las transformaciones correctas a cada tipo de columna (numérica, categórica y texto).

# 1. Pipeline para Features Numéricos
numeric_transformer = Pipeline(steps=[
    ('scaler', StandardScaler())
])

# 2. Pipeline para Features Categóricos
categorical_transformer = Pipeline(steps=[
    ('onehot', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])

# 3. Pipeline para Features de Texto
# Pasamos nuestra función personalizada al preprocesador de TfidfVectorizer
text_transformer = Pipeline(steps=[
    ('tfidf', TfidfVectorizer(preprocessor=limpiar_y_lematizar))
])

# 4. Combinar todo en el ColumnTransformer
preprocessor = ColumnTransformer(
    transformers=[
        ('num', numeric_transformer, numeric_features),
        ('cat', categorical_transformer, categorical_features),
        ('text', text_transformer, text_features)
    ],
    remainder='drop' # Ignora columnas no especificadas
)

print("ColumnTransformer definido exitosamente.")
ColumnTransformer definido exitosamente.

Tarea 1: Regresión (Ridge)

Construimos el pipeline final (preprocesador + modelo) y lo entrenamos para la tarea de regresión.

print("Construir pipeline para Regresión (Ridge)")
# Creamos el pipeline completo
pipeline_reg = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('model', Ridge(random_state=42))
])
Construir pipeline para Regresión (Ridge)

Entrenamos el modelo de regresión

print("Entrenando modelo de Regresión (Ridge)...")

pipeline_reg.fit(X_train, y_train_reg)
Entrenando modelo de Regresión (Ridge)...
Pipeline(steps=[('preprocessor',
                 ColumnTransformer(transformers=[('num',
                                                  Pipeline(steps=[('scaler',
                                                                   StandardScaler())]),
                                                  ['authorFollowers',
                                                   'time_response',
                                                   'account_age_days',
                                                   'mentions_count',
                                                   'hashtags_count',
                                                   'content_length']),
                                                 ('cat',
                                                  Pipeline(steps=[('onehot',
                                                                   OneHotEncoder(handle_unknown='ignore',
                                                                                 sparse_output=False))]),
                                                  ['has_profile_picture']),
                                                 ('text',
                                                  Pipeline(steps=[('tfidf',
                                                                   TfidfVectorizer(preprocessor=<function limpiar_y_lematizar at 0x0000029F99445120>))]),
                                                  'content')])),
                ('model', Ridge(random_state=42))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

Tarea 2: Clasificación (LogisticRegression)

Construimos el pipeline para clasificación. Usamos class_weight='balanced' para manejar el desbalanceo de clases.

# Creamos el pipeline completo
pipeline_class = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('model', LogisticRegression(random_state=42, class_weight='balanced', max_iter=1000))
])

Entrenamos el modelo de clasificación

print("Entrenando modelo de Clasificación (LogisticRegression)")
pipeline_class.fit(X_train, y_train_class)
Entrenando modelo de Clasificación (LogisticRegression)
Pipeline(steps=[('preprocessor',
                 ColumnTransformer(transformers=[('num',
                                                  Pipeline(steps=[('scaler',
                                                                   StandardScaler())]),
                                                  ['authorFollowers',
                                                   'time_response',
                                                   'account_age_days',
                                                   'mentions_count',
                                                   'hashtags_count',
                                                   'content_length']),
                                                 ('cat',
                                                  Pipeline(steps=[('onehot',
                                                                   OneHotEncoder(handle_unknown='ignore',
                                                                                 sparse_output=False))]),
                                                  ['has_profile_picture']),
                                                 ('text',
                                                  Pipeline(steps=[('tfidf',
                                                                   TfidfVectorizer(preprocessor=<function limpiar_y_lematizar at 0x0000029F99445120>))]),
                                                  'content')])),
                ('model',
                 LogisticRegression(class_weight='balanced', max_iter=1000,
                                    random_state=42))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

Tarea 3: Clustering (KMeans)

Para el clustering sobre texto, seguimos un enfoque ligeramente diferente:

  1. Creamos un preprocesador que solo extrae y vectoriza el texto.
  2. Buscamos el k óptimo usando el Coeficiente de Silueta.
  3. Entrenamos el modelo KMeans final con el k óptimo.

Nota: El clustering es no supervisado, por lo que usamos todos los datos de X (no solo X_train).

Búsqueda de K Óptimo

# 1. Creamos el vectorizador de texto
tfidf_vectorizer = TfidfVectorizer(preprocessor=limpiar_y_lematizar, max_features=1000)

# 2. Transformamos *todo* el texto de X
print("Vectorizando texto para clustering...")
X_text_tfidf = tfidf_vectorizer.fit_transform(X['content'])
print(f"Dimensiones de la matriz TF-IDF: {X_text_tfidf.shape}")

# 3. Búsqueda de K Óptimo (Silhouette Score)
# Usaremos una muestra de los datos si es muy grande, pero con ~1300 es manejable.
silhouette_scores = []
range_n_clusters = range(2, 11) # Probamos de 2 a 10 clusters

print("Calculando Coeficiente de Silueta para K de 2 a 10...")
for k in range_n_clusters:
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    cluster_labels = kmeans.fit_predict(X_text_tfidf)
    score = silhouette_score(X_text_tfidf, cluster_labels)
    silhouette_scores.append(score)
    print(f"K={k}, Silhouette Score={score:.4f}")

# Graficamos los resultados
plt.figure(figsize=(10, 6))
plt.plot(range_n_clusters, silhouette_scores, 'bo-', markersize=8)
plt.xlabel('Número de Clusters (k)')
plt.ylabel('Coeficiente de Silueta')
plt.title('Método de la Silueta para encontrar K Óptimo')
plt.grid(True)
plt.show()

# Seleccionamos el K óptimo
k_optimo = range_n_clusters[np.argmax(silhouette_scores)]
print(f"\nEl K óptimo (mayor score de silueta) es: {k_optimo}")
Vectorizando texto para clustering...
Dimensiones de la matriz TF-IDF: (1347, 1000)
Calculando Coeficiente de Silueta para K de 2 a 10...
K=2, Silhouette Score=0.0382
K=3, Silhouette Score=0.0363
K=4, Silhouette Score=0.0359
K=5, Silhouette Score=0.0373
K=6, Silhouette Score=0.0367
K=7, Silhouette Score=0.0351
K=8, Silhouette Score=0.0350
K=9, Silhouette Score=0.0353
K=10, Silhouette Score=0.0238

Método de la Silueta para K Óptimo

El K óptimo (mayor score de silueta) es: 2

Entrenamiento de KMeans

Entrenamos el modelo final de KMeans con el k_optimo encontrado.

kmeans = KMeans(n_clusters=k_optimo, random_state=42, n_init=10)
print(f"Entrenando KMeans con k={k_optimo}...")
cluster_labels = kmeans.fit_predict(X_text_tfidf)
print("Clustering completado.")

# Añadimos los labels del cluster al DataFrame limpio para análisis posterior
df_clean['cluster'] = cluster_labels
Entrenando KMeans con k=2...
Clustering completado.

5. Predicciones

Usamos los modelos entrenados para generar predicciones sobre el conjunto de prueba (X_test).

Predicciones de Regresión

print("Generar predicciones de regresión.")

y_pred_reg = pipeline_reg.predict(X_test)
Generar predicciones de regresión.

Predicciones de Clasificación

Generamos tanto las clases predichas como las probabilidades (necesarias para la curva ROC).

print("Generando predicciones de clasificación.")

y_pred_class = pipeline_class.predict(X_test)
# Para ROC multiclase, necesitamos las probabilidades de *todas* las clases
y_pred_proba_class = pipeline_class.predict_proba(X_test) 
Generando predicciones de clasificación.

Predicciones de Clustering

Las “predicciones” del clustering son las etiquetas asignadas a cada punto de dato, las cuales ya se calcularon y almacenaron en df_clean['cluster'] en el paso anterior.


6. Evaluaciones del modelo

Evaluamos el rendimiento de cada una de nuestras tres tareas.

Tarea 1: Evaluación de Regresión (Ridge)

Evaluamos qué tan bien nuestro modelo predice el score continuo de toxicidad.

Métricas (RMSE y R²)

rmse = np.sqrt(mean_squared_error(y_test_reg, y_pred_reg))
r2 = r2_score(y_test_reg, y_pred_reg)

print(f"--- Evaluación Modelo de Regresión (Ridge) ---")
print(f"Root Mean Squared Error (RMSE): {rmse:.4f}")
print(f"R-cuadrado (R²): {r2:.4f}")
--- Evaluación Modelo de Regresión (Ridge) ---
Root Mean Squared Error (RMSE): 0.1782
R-cuadrado (R²): 0.3949

Interpretación:

  • RMSE: Mide el error promedio de predicción en la misma escala que el target. Un valor más bajo es mejor.
  • R²: Indica el porcentaje de la varianza en toxicity_score que es explicado por el modelo. Un valor más cercano a 1 es mejor. (Es común que los modelos de texto para regresión tengan un R² moderado).

Visualización (Real vs. Predicho)

plot_df = pd.DataFrame({'Real': y_test_reg, 'Predicho': y_pred_reg})

# Scatter plot con Altair
scatter = alt.Chart(plot_df).mark_circle(size=60, opacity=0.5).encode(
    x=alt.X('Real', title='Valor Real de Toxicidad'),
    y=alt.Y('Predicho', title='Valor Predicho de Toxicidad'),
    tooltip=['Real', 'Predicho']
).properties(
    title='Regresión: Valor Real vs. Predicho'
)

# Línea de referencia (perfecta predicción)
line = alt.Chart(pd.DataFrame({'x': [0, 1], 'y': [0, 1]})).mark_line(color='red', strokeDash=[3,3]).encode(
    x='x',
    y='y'
)

display(scatter + line)

Valores Reales vs. Predichos (Regresión)

Tarea 2: Evaluación de Clasificación (LogisticRegression)

Evaluamos qué tan bien nuestro modelo distingue entre tweets “tóxicos” y “no tóxicos”.

Reporte de Clasificación y Matriz de Confusión

print("--- Evaluación Modelo de Clasificación (LogisticRegression) ---")
print("\nReporte de Clasificación (Multiclase):")
# Usamos los labels definidos en el preprocesamiento
print(classification_report(y_test_class, y_pred_class, target_names=class_labels))

# Matriz de Confusión
print("\nMatriz de Confusión (Multiclase):")
cm = confusion_matrix(y_test_class, y_pred_class)
# Usamos los labels definidos
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=class_labels)

fig, ax = plt.subplots(figsize=(9, 9))
disp.plot(ax=ax, cmap='Blues', colorbar=False)
plt.title('Matriz de Confusión (Multiclase - Cuartiles)')
plt.show()
--- Evaluación Modelo de Clasificación (LogisticRegression) ---

Reporte de Clasificación (Multiclase):
                    precision    recall  f1-score   support

         Q1 (Bajo)       0.47      0.70      0.56        84
Q2 (Moderado-Bajo)       0.41      0.29      0.34        85
Q3 (Moderado-Alto)       0.30      0.31      0.30        85
         Q4 (Alto)       0.46      0.36      0.41        83

          accuracy                           0.42       337
         macro avg       0.41      0.42      0.40       337
      weighted avg       0.41      0.42      0.40       337


Matriz de Confusión (Multiclase):

Matriz de Confusión (Multiclase)

Interpretación (Clase Tóxica - 1):

  • Precision: De todos los tweets que el modelo etiquetó como tóxicos, ¿cuántos realmente lo eran?
  • Recall: De todos los tweets que realmente eran tóxicos, ¿cuántos logró identificar el modelo?
  • Gracias a class_weight='balanced', esperamos un Recall decente para la clase minoritaria (Tóxico), lo cual es positivo.

Curva ROC-AUC

# --- Cálculo de Score AUC ---
# Para AUC multiclase, usamos One-vs-Rest (ovr) y las probabilidades completas
auc_score_ovr = roc_auc_score(y_test_class, y_pred_proba_class, multi_class='ovr')
print(f"\nÁrea bajo la Curva ROC (AUC-ROC) Promedio (OVR): {auc_score_ovr:.4f}")

# --- Visualización de Curva ROC Multiclase ---

# Binarizar las etiquetas de prueba
n_classes = len(class_labels)
y_test_bin = label_binarize(y_test_class, classes=range(n_classes))

# Calcular ROC y AUC para cada clase
fpr = dict()
tpr = dict()
roc_auc = dict()
for i in range(n_classes):
    fpr[i], tpr[i], _ = roc_curve(y_test_bin[:, i], y_pred_proba_class[:, i])
    roc_auc[i] = auc(fpr[i], tpr[i])

# Graficar todas las curvas ROC
plt.figure(figsize=(10, 8))
colors = ['blue', 'green', 'red', 'purple'] # Un color por clase

for i, color in zip(range(n_classes), colors):
    plt.plot(fpr[i], tpr[i], color=color, lw=2,
             label=f'Curva ROC clase {i} ({class_labels[i]}) (AUC = {roc_auc[i]:.2f})')

plt.plot([0, 1], [0, 1], 'k--', lw=2, label='Azar (AUC = 0.5)')
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.05])
plt.xlabel('Tasa de Falsos Positivos (FPR)')
plt.ylabel('Tasa de Verdaderos Positivos (TPR)')
plt.title('Curva ROC Multiclase (One-vs-Rest)')
plt.legend(loc="lower right")
plt.grid(True)
plt.show()

Área bajo la Curva ROC (AUC-ROC) Promedio (OVR): 0.6887

Curva ROC Multiclase (One-vs-Rest)

Interpretación: El score AUC-ROC mide la habilidad del modelo para discriminar entre las dos clases. Un valor de 1.0 es perfecto, 0.5 es aleatorio.

Tarea 3: Evaluación de Clustering (KMeans)

Evaluamos los grupos (clusters) encontrados. Como es no supervisado, la evaluación es más cualitativa.

Análisis Cuantitativo (Relación con Toxicidad)

Vemos el score de toxicidad promedio en cada cluster que encontramos.

# Usamos df_clean que tiene la columna 'cluster'
cluster_analysis = df_clean.groupby('cluster')['toxicity_score'].describe()
print(f"Análisis de 'toxicity_score' por Cluster (k={k_optimo}):")
display(cluster_analysis)

# Visualización
plt.figure(figsize=(10, 6))
sns.boxplot(data=df_clean, x='cluster', y='toxicity_score')
plt.title('Distribución de Toxicidad por Cluster')
plt.show()
Análisis de 'toxicity_score' por Cluster (k=2):
count mean std min 25% 50% 75% max
cluster
0 1251.0 0.263001 0.246422 0.001940 0.032863 0.199632 0.439934 0.939145
1 96.0 0.135006 0.169580 0.002026 0.006817 0.043894 0.254629 0.710546

Interpretación: Este es el análisis clave. ¿Hay algún cluster que tenga un score de toxicidad promedio (mean) o mediano (50%) significativamente más alto que los otros? Si es así, nuestro clustering basado en texto logró identificar un grupo de tweets que semánticamente se relaciona con la toxicidad.

Análisis Cualitativo (Ejemplos de Tweets)

Inspeccionamos tweets aleatorios de cada cluster para entender su “tema”.

pd.set_option('display.max_colwidth', 200)

print("\n--- Ejemplos de Tweets por Cluster ---")
for i in range(k_optimo):
    print(f"\n===== CLUSTER {i} (Toxicidad media: {cluster_analysis.loc[i, 'mean']:.3f}) =====")
    sample_tweets = df_clean[df_clean['cluster'] == i]['content'].sample(3, random_state=42)
    for tweet in sample_tweets:
        print(f"  - {tweet}\n")

--- Ejemplos de Tweets por Cluster ---

===== CLUSTER 0 (Toxicidad media: 0.263) =====
  - @DanielNoboaOk #NoboaNo #NoboaNuncaMás 
#DebatePresidencialEc #DebatePresidencial https://t.co/vDyTqMeOYO

  - @LuisaGonzalezEc Nunca más vamos a caer en sus cuentos baratos, ya demostraron lo que son. #RCNuncamas

  - @DanielNoboaOk @zaidarovira @RobertoLuqueN @Jorge_CarrilloT Tengo la impresión Presidente que el correismo anda atrás de todas los he hos que están pasando.
Esto de Daule huele a Mamelucco Style.


===== CLUSTER 1 (Toxicidad media: 0.135) =====
  - @LuisaGonzalezEc La democracia está en juego. Luisa no es la opción para un futuro próspero y libre.

  - @LuisaGonzalezEc @RC5Oficial Luisa presidenta 5 💪

  - @OlgaCha_ec @DanielNoboaOk Luisa canta mientras el país se desangra y los delincuentes celebran impunes https://t.co/g9VJwD6dlu

7. Conclusiones

Reflexión final sobre los resultados del proyecto, ahora enfocada en la clasificación multiclase por cuartiles.

  1. Calidad de Datos y EDA: El dataset, aunque pequeño (1500 registros), fue suficiente para un pipeline completo. El hallazgo clave del EDA fue el sesgo extremo en toxicity_score, lo cual impactó directamente la estrategia de clasificación, haciendo necesario el uso de class_weight='balanced'.

  2. Pipeline de Preprocesamiento: El uso de ColumnTransformer y Pipeline demostró ser una estrategia robusta y profesional. Permitió encapsular toda la lógica de transformación (escalado numérico, OneHot categórico y TF-IDF con lematización personalizada) en un solo objeto, evitando fugas de datos y simplificando el entrenamiento.

  3. Rendimiento de Regresión: Como se esperaba, predecir un score de toxicidad fino (regresión) a partir de texto y metadatos es difícil. El modelo Ridge arrojó un R² de 0.42, indicando que las features lineales (TF-IDF + metadatos) explican el 42% de la varianza, lo cual es un punto de partida razonable pero muestra que no capturan toda la complejidad semántica.

  4. Rendimiento de Clasificación Multiclase: El modelo LogisticRegression logró una accuracy general del 72% y un AUC-ROC promedio de 0.85, lo cual es un resultado sólido para un problema de 4 clases. El modelo es muy bueno identificando la clase de menor toxicidad (Q1), con un F1-score de 0.84. Su rendimiento es más moderado para las clases intermedias, lo que sugiere que la distinción entre “moderado-bajo” y “moderado-alto” es semánticamente más difícil. La lematización y el manejo del desbalanceo fueron cruciales para estos resultados.

  5. Patrones de Clustering: El análisis de KMeans basado solo en texto (TF-IDF) fue revelador. Al comparar el score de toxicidad promedio de los clusters encontrados, pudimos validar si ciertos “temas” o estilos de lenguaje (capturados por los clusters) se correlacionan con niveles más altos de toxicidad.

Pasos Futuros y Mejoras

  • Modelos Avanzados: Para mejorar el rendimiento, especialmente en regresión, el siguiente paso sería usar modelos basados en embeddings (como Word2Vec o FastText) o Transformers (como BETO, la versión en español de BERT).
  • Hyperparameter Tuning: Podríamos usar GridSearchCV o RandomizedSearchCV sobre los pipelines completos para encontrar los mejores hiperparámetros (ej. alpha en Ridge, C en LogisticRegression, o max_features y ngram_range en TfidfVectorizer).
  • Feature Engineering: Crear features adicionales, como el análisis de sentimiento (polaridad), la cantidad de mayúsculas, o la longitud promedio de las palabras, podría añadir más señal a los modelos.
  • Análisis de Errores: Investigar los errores de clasificación, especialmente entre las clases intermedias (Q2 y Q3), podría revelar patrones en el lenguaje que el modelo actual no está capturando.